fix(ops): schedule /api/cron/* from GitHub Actions and reconcile cutoff on usage change (#513) - #519
Draft
guillermoscript wants to merge 1 commit into
Draft
fix(ops): schedule /api/cron/* from GitHub Actions and reconcile cutoff on usage change (#513)#519guillermoscript wants to merge 1 commit into
guillermoscript wants to merge 1 commit into
Conversation
…ff on usage change (#513) Production runs on Dokploy, which does not read `vercel.json`. Querying the Dokploy API for schedules on the LMS app, the MCP app, and at server scope all return empty, and there is no separate host holding a crontab — so none of the seven `/api/cron/*` routes have ever run in production. Access-cutoff enforcement (#494), platform-subscription expiry (#462), the daily digest, league rollover and both payment reconcilers were all inert. Add `.github/workflows/cron.yml`, which schedules all seven routes using the `Authorization: Bearer $CRON_SECRET` header each route already implements, one schedule entry per cadence declared in `vercel.json`, plus a `workflow_dispatch` route picker for on-demand runs. It requires a `CRON_SECRET` repository secret and a `CRON_BASE_URL` repository variable, and fails loudly when either is missing rather than silently no-opping. `vercel.json` is left in place. Also call `reconcileAccessCutoff()` from `joinCurrentSchool()` and `createCourse()` immediately after their inserts succeed, wrapped in try/catch so a reconciliation or email failure can never fail a user action that already succeeded. This is defense-in-depth rather than a live bug fix: both pre-flight checks block at `>=` while `computePlanLimitViolations` flags at `>`, so a legitimate join or course creation stops at the limit. The value is in catching drift between the three independent limit implementations, and in clearing a stale cutoff as soon as a tenant drops back under its limit. Docs: `OPERATIONS_GUIDE.md` no longer tells the operator to configure Vercel Cron, and `DEPLOYMENT.md` §3.5 now lists all seven routes across three mutually-exclusive scheduling options instead of four routes in one. `solana-pull` remains deliberately unscheduled — it submits on-chain USDC transfers and belongs in its own reviewed change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xmiu65sTJVVgFjdn9xRKK2
This was referenced Jul 25, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #513.
What was actually wrong
I checked the premise against production before writing any code. Querying the Dokploy API for schedules on the LMS app (
schedule.list,wTNg0pha2N1VUYgzeB8-a), on the MCP app, and atdokploy-serverscope all return[], andserver.allis empty so there is no separate host holding a crontab either.There is no scheduler in production. All seven
/api/cron/*routes have never run there.That is broader than the access-cutoff symptom the issue was filed for.
tenants.access_cutoff_atis only written byreconcileAccessCutoff(), and five of its six call sites hang off a plan-change event; the sixth —enforce-plan-limits— is the only path that fires for a tenant that grows over its limits organically, and it's declared exclusively invercel.json, which Dokploy does not read. The same silence applies to platform-subscription expiry (#462), the daily digest, league rollover, and both payment reconcilers.Two corrections to the issue's evidence, both of which changed the shape of the fix:
docs/DEPLOYMENT.mdwas not silent on scheduling. It already had a### 3.5 Cron Jobssection with a host-crontab example — but listing only 4 of the 8 routes, and nobody ever ran it. So this was a "complete it and make it executable" job, not a "write it from scratch" job.app/api/cron/but only 7 entries invercel.json.solana-pullhas never been scheduled anywhere despite its own docstring saying it should run hourly. See "Deliberately out of scope" below.Changes
1.
.github/workflows/cron.yml(new) — schedules all seven routes, oneon.scheduleentry per distinct cadence already declared invercel.json, with acaseblock mappinggithub.event.scheduleto the route(s) that tick belongs to. Auth reuses theAuthorization: Bearer $CRON_SECRETheader every route already implements, so no application code changed to support this. Aworkflow_dispatchroute picker makes any single job runnable on demand — that's what makes this verifiable at review time rather than something you find out about in 24 hours.I chose GitHub Actions over a Dokploy scheduled task deliberately: it lives in the repo, it's reviewable in this PR, and a reviewer can verify it without prod console access. Its weaknesses are real and are documented in the file's header comment and in DEPLOYMENT.md — GitHub delays scheduled runs under load (dropping high-frequency ones first, so the
*/10reconcilers are the most affected), and it disables scheduled workflows after 60 days of repository inactivity. Neither is disqualifying for daily billing sweeps on an active repo, but the operator now has the Dokploy scheduled task documented as the belt-and-braces alternative.vercel.jsonis left as-is — harmless, and removing it would strand the project if it's ever deployed to Vercel.2.
reconcileAccessCutoff()at the two usage-crossing sites —app/actions/join-school.tsafter thetenant_usersinsert succeeds, andapp/actions/teacher/courses.tsafter thecoursesinsert succeeds. Both already hold an admin client, which is what the function needs. Both calls are wrapped intry/catchthat logs and swallows: reconciliation (or an email failure inside it) must never fail a join or a course creation that already succeeded. The function is idempotent by design — it only schedules whenviolations.length > 0 && !currentCutoffAt— so re-entry from the cron is safe.Being straight about what this second change buys, because the issue slightly overstates it: it is defense-in-depth, not a live bug fix.
computePlanLimitViolationscompares with strict>, while both pre-flight checks block at>=(join-school.tsatcount >= maxStudents,checkCourseLimitatcurrentCount < limit). A legitimate join or course creation therefore stops at the limit and cannot produce a violation. The value is in the drift between the duplicated checks —join-school.tsfalls back to a hardcoded50when theplatform_plansrow is missing or inactive, andcheckCourseLimitcounts all courses whilecountTenantUsagecounts only non-archived ones. Where those disagree a crossing can slip past the pre-flight, and this catches it at the moment it happens instead of up to 24h later. It also clears a stale cutoff the moment a tenant drops back under its limit.3. Docs —
docs/OPERATIONS_GUIDE.md§4d was telling the operator to "Configure Vercel Cron" and named onlyexpire-subscriptions; it now describes the actual scheduler, its two required repo settings, and how to verify it.docs/DEPLOYMENT.md§3.5 now presents three mutually-exclusive options (Actions / Dokploy scheduled task / host crontab) with all seven routes listed in each, and states plainly that running two of them means every job fires twice.Required before this does anything
This PR does not become effective on merge. Two repository settings must be added under Settings → Secrets and variables → Actions:
CRON_SECRETCRON_SECRETenv var already set on the Dokploy appCRON_BASE_URLhttps://preciopana.comThe workflow fails loudly if either is missing rather than silently no-opping, which is precisely the failure mode being fixed here. A
401in the run log means the twoCRON_SECRETvalues don't match.Testing
npm run test:unit— 287/287 passing across 27 files (283 before this branch, plus 4 new). Newtests/unit/access-cutoff-call-sites.test.tscovers both call sites: thatreconcileAccessCutoffis called exactly once with the tenant id, and that a rejectingreconcileAccessCutoffstill letsjoinCurrentSchool()return{ success: true }andcreateCourse()resolve with the course.reconcileAccessCutoffcall from both call sites and re-ran: all 4 fail (Test Files 1 failed,Tests 4 failed). Restored and re-verified the tree is clean.npx tsc --noEmit— no errors in any changed file.npx eslint app/actions/join-school.ts app/actions/teacher/courses.ts— clean.npm run build— passes.vercel.jsonroutes covered, 8 dispatch options). Thecaseblock was extracted and executed against all six schedule strings plus one unmapped string, confirming each maps to the right route and that an unmapped schedule exits non-zero instead of silently doing nothing.actionlintis not installed locally, so the workflow has not been through it — worth a glance from the reviewer.QA script — the one check that proves this works
Everything above verifies the code; only this verifies the mechanism, and it can only run after the two settings above are added and this is merged to
master(GitHub only offersworkflow_dispatchfor workflows present on the default branch):Expect
HTTP 200and a body like{"success":true,"scheduled":0,"cleared":0,"none":N,"errors":0}whereNis your tenant count.401→ theCRON_SECRETvalues don't match. Any other code → the route or the base URL is wrong.Then confirm the schedules actually fire: check the Actions tab ~24h later for a green run on the
0 3 * * *tick.Deliberately out of scope
Both worth filing as follow-ups rather than smuggling in here:
solana-pullis still not scheduled. It submits on-chain USDC transfers for native Solana subscriptions and has never had a scheduler invercel.jsoneither. Putting a money-moving job on a timer deserves its own reviewed change, so it's available underworkflow_dispatchonly and documented as intentionally unscheduled.lib/billing/plan-limits.ts,join-school.ts's inline check, andcheckCourseLimit()each compute limits independently with different fallbacks, different comparison operators, and different course-count semantics. Consolidating them ontolib/billing/plan-limits.tsis the real fix for the drift that change Add paypal as a payment method #2 above merely compensates for.No UI surface changed, so there are no screenshots.